가상화 기반 Masonry 레이아웃 직접 구현

마지막 수정일 · 2026. 04. 28.

TL;DR

사진과 영상이 섞여 column 단위로 떨어지지 않는 자유 배치 + 무한 스크롤 홈 화면이 요구사항이었는데, 기존 Masonry / windowing 라이브러리가 전부 요구사항을 못 맞춰서 둘 다 직접 구현함.

  • 배치 — 각 column의 누적 높이를 추적해 가장 낮은 곳에 넣는 FFD(First Fit Decreasing) 방식. 빈 공간을 남기지 않는 게 목적
  • 관측 — 배치 결과가 좋은지를 감으로 보지 않으려고, column 높이 편차를 해상도로 정규화해 로그로 쌓는 hook을 같이 만듦
  • 가상화useMeasureHeight(ResizeObserver로 실측) + useWindowing(누적 높이로 start/end index 계산) 두 hook으로 windowing을 직접 얹음
  • 라이브러리를 안 쓴 이유는 성능이 아니라 요구사항 대비 소모 리소스. 커스텀 비용이 직접 구현 비용을 넘어가는 지점이었음

디자인/QA 쪽 이야기는 Framer Code Component 기반 디자인 QA 프로세스로 이어짐.



문제상황

홈 화면은 셀럽 콘텐츠에서 뽑아낸 코디 이미지가 쌓이는 피드였다. 여기에 두 가지 제약이 있었다.

  1. 다양한 비율의 사진 + 9:16 비율의 비디오가 섞인다. 아이템 높이가 제각각이다
  2. 무한 스크롤이다. 전체 아이템 목록을 미리 알 수 없다

높이가 제각각인 아이템을 grid로 깔면 짧은 column 아래로 빈 공간이 생긴다. 모바일에서 이 빈 공간은 그냥 '못생김'이 아니라 스크롤 한 화면당 보여줄 수 있는 콘텐츠 수가 줄어드는 문제였다. 그래서 벽돌 쌓듯 어긋나게 배치하는 Masonry 레이아웃이 필요했다.


라이브러리를 먼저 봤는데 둘 다 걸렸다.

후보 막힌 지점
react-virtualized Masonry 위치 계산을 prop으로 받아야 한다. 아이템이 페이징으로 비동기 로딩되는 구조와 맞지 않음
react-window 계열 높이 가상화 스트레스 테스트에서 아이템 위치가 깜빡이는 문제 발생

둘 다 "커스텀하면 되긴 하는" 수준이었지만, 커스텀 지점이 라이브러리의 핵심 가정(위치를 외부에서 결정 / 높이가 안정적)이라 손대는 순간 라이브러리를 쓰는 의미가 없어졌다.



배치 — FFD(Bin-Packing)

Masonry 배치는 결국 bin packing 문제다. column이 bin이고 아이템 높이가 물건이다. 온라인(아이템이 순차적으로 들어옴) 상황이라 최적해는 못 구하고, FFD 계열의 그리디로 갔다.

핵심은 "가장 낮은 column에 넣는다" 가 아니라 "넣었을 때 목표 높이에서 가장 덜 벗어나는 column에 넣는다" 로 잡은 것이다. 단순히 최소 높이 column만 고르면 큰 아이템이 들어올 때 편차가 한 번에 벌어진다.

tsx
useEffect(() => {
  // ...생략
  sortedImages.forEach((img) => {
    const height = 1 / img.aspectRatio;
    const bestCol = heights.reduce(
      (best, _, i) =>
        Math.abs(heights[i] + height - targetHeight) <
        Math.abs(heights[best] + height - targetHeight)
          ? i
          : best,
      0
    );

    newColumns[bestCol].push(img);
    heights[bestCol] += height;
  });

높이를 1 / aspectRatio로 다루는 게 포인트다. column 폭을 1로 정규화한 상대 높이라서, 실제 픽셀 폭이 얼마든(기기 해상도가 뭐든) 같은 배치 결과가 나온다. 이미지가 로드되기 전에도 aspectRatio만 알면 계산되므로, 비동기 로딩과도 충돌하지 않는다. react-virtualized에서 막혔던 지점이 여기서 풀렸다.

렌더는 계산된 column 배열을 그대로 그린다.

tsx
return (
  <MasonryWrapper gap={gap}>
    {columns.map((col, i) => (
      <Column key={i} gap={gap}>
        {col.map((img) => (
          <FeedImage
            key={img.id}
            src={img.url}
            aspectRatio={img.aspectRatio}
          />
        ))}
      </Column>
    ))}
  </MasonryWrapper>
);


배치 품질을 로그로 관측하기

여기서 하나 더 붙였다. 배치가 잘 됐는지를 눈으로 보고 판단하지 않으려고 로깅 hook을 만들었다.

ts
export function useMasonryGapLog({ colHeights, screenWidth }: GapLogProps) {
  // ...생략

  const max = Math.max(...colHeights);
  const min = Math.min(...colHeights);
  const diff = max - min;
  const ratio = (diff / screenWidth) * 100;

  logEvent({
    category: "masonry",
    action: "column_gap_logged",
    label: "normalized_gap_ratio",
    value: Math.round(ratio),
    metadata: {
      columnCount: colHeights.length,
      diffInPx: diff,
      screenWidth,
      timestamp: Date.now(),
    },
  });

column 높이 편차(max - min)를 그대로 쌓으면 기기별로 비교가 안 된다. 큰 화면에서는 편차 100px가 아무것도 아니지만 작은 화면에서는 눈에 띈다. 그래서 화면 폭으로 나눠 정규화한 비율을 지표로 삼았다.

이렇게 해두니 "지금 배치 알고리즘이 실사용 데이터에서 얼마나 어긋나는가"를 개발자 감이 아니라 분포로 볼 수 있었다. 레이아웃 알고리즘을 바꿀 때 A/B 비교의 기준도 이 값이 됐다.



무한 스크롤이 붙자 생긴 문제

배치가 끝나고 무한 스크롤을 붙이자 성능 문제가 나왔다. 세 방향이었다.

  • 초기 렌더링 지연 — 대량 DOM 생성 + 이미지 로딩 + 레이아웃 계산 부하가 한 번에 걸림
  • 과도한 메모리 사용 — 화면 밖 요소까지 전부 렌더링해서 리소스를 낭비
  • 모바일 스크롤 버벅임 — 긴 DOM 트리로 렌더링 병목과 프레임 드랍

세 번째가 제일 급했다. 앞의 둘은 숫자로만 나쁘지만, 스크롤 버벅임은 사용자가 바로 체감한다. 홈 화면 = 무한 스크롤인 서비스에서 스크롤이 끊기면 서비스 전체가 느린 걸로 인식된다.



windowing 라이브러리를 안 쓴 이유

여기서도 라이브러리를 먼저 검토했다. react-virtualized, react-window, @tanstack/react-virtual 셋 다 잘 관리되고 있었고 다운로드 수도 충분했다.

문제는 성능이 아니라 요구사항 대비 소모 리소스였다. 위 오른쪽 그래프가 그때 정리한 판단 기준이다.

  • 요구사항이 단순한 구간에서는 라이브러리가 압도적으로 싸다
  • 그런데 요구사항 복잡도가 올라가면 커스텀 비용이 지수적으로 붙는다. 라이브러리의 가정을 벗어나는 순간부터 라이브러리와 싸우게 되기 때문이다
  • 직접 구현은 초기 비용이 높지만 복잡도에 대해 완만하게 증가한다

우리 요구사항(column 단위로 안 떨어지는 자유 배치 + 비동기 페이징 + 높이 실측 보정)은 이미 교차점 오른쪽에 있었다. 그래서 직접 구현으로 갔다. "라이브러리를 쓰지 말자"가 아니라 "우리 요구사항은 교차점 어디에 있는가"를 먼저 그린 것이 이 판단의 전부다.



두 개의 hook으로 만든 windowing

windowing을 두 개의 hook으로 쪼갰다. 높이를 재는 책임무엇을 그릴지 정하는 책임을 분리한 것이다.

useMeasureHeight — ResizeObserver로 frame별 갱신

ts
export function useMeasureHeight({ onChange }: UseMeasureHeightOptions) {
  const ref = useRef<HTMLElement | null>(null);
  const observerRef = useRef<ResizeObserver | null>(null);
  const previousHeightRef = useRef(0);

  useEffect(() => {
    if (!ref.current) return;

    observerRef.current = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const height = entry.contentRect.height;
        if (height !== previousHeightRef.current) {
          previousHeightRef.current = height;
          onChange(height);
        }
      }
    });

    observerRef.current.observe(ref.current);

    return () => {
      observerRef.current?.disconnect();
    };
  }, [onChange]);

  return ref;
}

previousHeightRef이전 높이와 같으면 아예 onChange를 안 쏘는 것이 중요했다. ResizeObserver는 레이아웃이 안정된 뒤에도 콜백이 반복해서 들어올 수 있는데, 그때마다 상위 상태를 갱신하면 측정 → 리렌더 → 측정 루프가 돈다. react-window 테스트에서 봤던 "아이템 위치가 깜빡이는" 증상이 딱 이 계열이었다.

useWindowing — 누적 높이로 start / end index 계산

ts
export function useWindowing({
  scrollTop,
  containerHeight,
  itemHeights,
  overscan = 600, // 600px(휴대폰 높이 기준)
}: UseWindowingOptions) {
  // ...생략
  const { startIndex, endIndex, offsetTop } = useMemo(() => {
    // find start index
    for (let i = 0; i < itemHeights.length; i++) {
      if (total + itemHeights[i] > scrollTop - overscan) {
        start = i;
        offset = total;
        break;
      }
      total += itemHeights[i];
    }

    // find end index
    total = offset;
    for (let i = start; i < itemHeights.length; i++) {
      total += itemHeights[i];
      if (total > scrollTop + containerHeight + overscan) {
        end = i;
        break;
      }
    }

    return { startIndex: start, endIndex: end, offsetTop: offset };
  }, [scrollTop, containerHeight, itemHeights, overscan]);

  • useMemo 의존성이 [scrollTop, containerHeight, itemHeights, overscan] 뿐이라, 스크롤이 멈춰 있으면 계산이 아예 안 돈다. 여기에 아이템 컴포넌트를 memo로 감싸면 스크롤 중 실제 리렌더 대상은 새로 들어오고 나가는 아이템뿐이 된다
  • overscan = 600은 임의의 숫자가 아니라 휴대폰 화면 높이 한 장분이다. 화면 한 장 앞까지 미리 그려두면 빠르게 튕기듯 스크롤해도 빈 화면이 안 보인다
  • offsetTop을 같이 반환해서 상단 스페이서 높이로 쓴다. 이게 있어야 잘라낸 앞부분만큼 스크롤 위치가 유지된다


결과

  • 무한 스크롤 깊이가 늘어나도 배치가 흔들리지 않는 홈 화면을 확보했다. 스크롤을 아무리 내려도 렌더링되는 DOM 수는 화면 + overscan 분량으로 일정하다
  • 무한 스크롤 최적화 전체(windowing + dynamic import + 리소스 지연 로딩 + 스켈레톤 UI) 기준으로 앱 진입 초기 로딩 1,500ms → 700ms, 평균 프레임 28fps → 53fps
  • 배치 품질이 normalized_gap_ratio 지표로 남아서, 이후 알고리즘 변경을 감이 아니라 분포 비교로 판단할 수 있게 됐다

여기서 얻은 판단 기준은 라이브러리 선택 일반으로도 남았다. "이 라이브러리가 좋은가"가 아니라 "우리 요구사항이 이 라이브러리의 가정 안에 있는가" 를 먼저 본다. 가정을 벗어나면 커스텀 비용이 직접 구현 비용을 금방 넘어선다.



남은 것 / 한계

  1. 아이템 높이 추정의 정확도
    aspectRatio 기반이라 이미지/비디오는 잘 맞지만, 텍스트가 붙는 카드 타입이 생기면 실측 전까지 높이를 모른다. 이 경우 실측 보정이 일어날 때마다 전체 높이가 변해 스크롤바가 튄다. 나중에 EMR 쪽에서 같은 문제를 만났고, 거기서는 캔버스 글리프 측정으로 풀었다 → 가상화 리스트의 동적 높이 추정 및 스크롤 성능 개선
  2. column 수 변경 시 전체 재계산
    화면 회전이나 리사이즈로 columnCount가 바뀌면 전체 배치를 다시 계산한다. 아이템이 수천 개 쌓인 뒤에는 이 계산이 한 프레임을 넘길 수 있다. 청크로 쪼개거나 이전 배치를 재사용하는 방법을 안 넣어둔 상태다
  3. itemHeights 배열 참조
    useMemo 의존성에 배열이 그대로 들어가 있어서, 상위에서 매번 새 배열을 만들면 memo가 무력화된다. 실사용에서는 문제가 없었지만 구조적으로는 위험한 지점이다